Browse Source

Merge pull request #1290 from bjorkert/fix-nightscout-duplicate-uploads

Fix duplicate Nightscout treatment uploads by serializing per-pipeline runs
Deniz Cengiz 14 hours ago
parent
commit
4bc16ca78d

+ 8 - 0
Trio.xcodeproj/project.pbxproj

@@ -539,6 +539,7 @@
 		BD8E6B212D9036CA00ABF8FA /* OnboardingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8E6B202D9036CA00ABF8FA /* OnboardingProvider.swift */; };
 		BD8E6B232D9036F700ABF8FA /* OnboardingDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8E6B222D9036F700ABF8FA /* OnboardingDataFlow.swift */; };
 		BD8FC0542D66186000B95AED /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0532D66186000B95AED /* TestError.swift */; };
+		FB9012AE66284A92A6531037 /* NightscoutUploadSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C39FF1EC12CF4B1EBB13BE26 /* NightscoutUploadSerializerTests.swift */; };
 		BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0562D66188700B95AED /* PumpHistoryStorageTests.swift */; };
 		BD8FC0592D66189700B95AED /* TestAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0582D66189700B95AED /* TestAssembly.swift */; };
 		BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */; };
@@ -782,6 +783,7 @@
 		DD868FD82E381A54005D3308 /* APNSJWTClaims.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */; };
 		DD88C8E22C50420800F2D558 /* DefinitionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD88C8E12C50420800F2D558 /* DefinitionRow.swift */; };
 		DD906BF42EA6AA0100262772 /* NightscoutUploadPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */; };
+		B63C9D934FC54853BC4EF29A /* NightscoutUploadSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */; };
 		DD906BF62EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD906BF52EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift */; };
 		DD940BAA2CA7585D000830A5 /* GlucoseColorScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */; };
 		DD940BAC2CA75889000830A5 /* DynamicGlucoseColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */; };
@@ -1544,6 +1546,7 @@
 		BD8E6B202D9036CA00ABF8FA /* OnboardingProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingProvider.swift; sourceTree = "<group>"; };
 		BD8E6B222D9036F700ABF8FA /* OnboardingDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingDataFlow.swift; sourceTree = "<group>"; };
 		BD8FC0532D66186000B95AED /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = "<group>"; };
+		C39FF1EC12CF4B1EBB13BE26 /* NightscoutUploadSerializerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadSerializerTests.swift; sourceTree = "<group>"; };
 		BD8FC0562D66188700B95AED /* PumpHistoryStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpHistoryStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0582D66189700B95AED /* TestAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAssembly.swift; sourceTree = "<group>"; };
 		BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationStorageTests.swift; sourceTree = "<group>"; };
@@ -1792,6 +1795,7 @@
 		DD868FD72E381A54005D3308 /* APNSJWTClaims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTClaims.swift; sourceTree = "<group>"; };
 		DD88C8E12C50420800F2D558 /* DefinitionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefinitionRow.swift; sourceTree = "<group>"; };
 		DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadPipeline.swift; sourceTree = "<group>"; };
+		10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploadSerializer.swift; sourceTree = "<group>"; };
 		DD906BF52EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseNightscoutManager+Subscribers.swift"; sourceTree = "<group>"; };
 		DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseColorScheme.swift; sourceTree = "<group>"; };
 		DD940BAB2CA75889000830A5 /* DynamicGlucoseColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicGlucoseColor.swift; sourceTree = "<group>"; };
@@ -3013,6 +3017,7 @@
 				3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */,
 				B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */,
 				BD8FC0532D66186000B95AED /* TestError.swift */,
+				C39FF1EC12CF4B1EBB13BE26 /* NightscoutUploadSerializerTests.swift */,
 				BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */,
 				CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */,
 			);
@@ -4264,6 +4269,7 @@
 			isa = PBXGroup;
 			children = (
 				DD906BF32EA6AA0100262772 /* NightscoutUploadPipeline.swift */,
+				10E61A0885164081B57C357C /* NightscoutUploadSerializer.swift */,
 				3811DE9725C9D88300A708ED /* NightscoutManager.swift */,
 				38FE826C25CC8461001FF17A /* NightscoutAPI.swift */,
 				DD906BF52EA6AAE900262772 /* BaseNightscoutManager+Subscribers.swift */,
@@ -4925,6 +4931,7 @@
 				CE7CA3552A064973004BE681 /* ListStateIntent.swift in Sources */,
 				C28DD7262DBA9A9E00EC02DD /* GlucosePercentileDetailView.swift in Sources */,
 				DD906BF42EA6AA0100262772 /* NightscoutUploadPipeline.swift in Sources */,
+				B63C9D934FC54853BC4EF29A /* NightscoutUploadSerializer.swift in Sources */,
 				BDF530D82B40F8AC002CAF43 /* LockScreenView.swift in Sources */,
 				195D80B72AF697B800D25097 /* DynamicSettingsDataFlow.swift in Sources */,
 				DD98ACC02D71013200C0778F /* StatChartUtils.swift in Sources */,
@@ -5641,6 +5648,7 @@
 				3BE2F1E82E030E2F009E2900 /* MealCobTests.swift in Sources */,
 				3BBC22632DF5B94100169236 /* AutosensTests.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
+				FB9012AE66284A92A6531037 /* NightscoutUploadSerializerTests.swift in Sources */,
 				DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */,
 				DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */,
 				DDDD10A12F4E22C000F9C645 /* CarbsNativeConversionTests.swift in Sources */,

+ 2 - 2
Trio/Sources/Services/Network/Nightscout/BaseNightscoutManager+Subscribers.swift

@@ -31,8 +31,8 @@ extension BaseNightscoutManager {
 
     /// Maps Core Data "not yet uploaded to Nightscout" sets to upload pipeline requests via
     /// NSFetchedResultsControllers. Each controller fires when un-uploaded items appear (or drop
-    /// out after a successful upload). We rely on the per-pipeline throttle so rapid changes
-    /// don't spam Nightscout.
+    /// out after a successful upload). Requests are coalesced and serialized per pipeline so
+    /// rapid changes don't spam Nightscout.
     func wireUploadControllers() {
         determinationUploadControllerDelegate.onContentChange = { [weak self] in
             self?.requestUpload(.deviceStatus)

+ 45 - 56
Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift

@@ -13,10 +13,7 @@ protocol NightscoutManager: GlucoseSource {
     func deleteCarbs(withID id: String) async
     func deleteInsulin(withID id: String) async
     func deleteGlucose(withID id: String, withDate date: Date) async
-    func uploadDeviceStatus() async throws
-    func uploadGlucose() async
     func uploadCarbs() async
-    func uploadPumpHistory() async
     func uploadOverrides() async
     func uploadTempTargets() async
     func uploadProfiles() async throws
@@ -44,56 +41,33 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue")
     private var ping: TimeInterval?
 
-    // Queue where upload pipelines run.
-    let uploadPipelineQueue = DispatchQueue(label: "NightscoutManager.uploadPipelines", qos: .utility)
-
-    /// Throttle window (seconds) per upload pipeline. Any requests inside this window
-    /// coalesce into a single upload run for that pipeline.
-    let uploadPipelineInterval: [NightscoutUploadPipeline: TimeInterval] = [
-        .carbs: 2, .pumpHistory: 2, .overrides: 2, .tempTargets: 2,
-        .glucose: 2, .deviceStatus: 2
-    ]
-
-    /// Subjects used to request an upload pipeline. The pipeline applies a throttle so
-    /// close calls don’t double-upload.
-    var uploadPipelineSubjects: [NightscoutUploadPipeline: PassthroughSubject<Void, Never>] = {
-        var d: [NightscoutUploadPipeline: PassthroughSubject<Void, Never>] = [:]
-        NightscoutUploadPipeline.allCases.forEach { d[$0] = PassthroughSubject<Void, Never>() }
-        return d
-    }()
+    /// Coalesces and serializes upload runs so no two runs of the same pipeline overlap.
+    /// Runs execute `performUpload(for:)`, provided at init. The public `upload*()`
+    /// methods and `requestUpload(_:)` go through the serializer; the `performUpload*()`
+    /// bodies must not call the awaitable `upload*()` entry points. The serializer
+    /// asserts on such a call in debug builds and downgrades it to a fire-and-forget
+    /// request in release.
+    private var uploadSerializer: NightscoutUploadSerializer!
 
     /// Request an upload for a pipeline (enqueue work). Safe to call from anywhere.
+    /// Bursts of requests coalesce: at most one run follows the one currently in flight.
     func requestUpload(_ uploadPipeline: NightscoutUploadPipeline) {
-        uploadPipelineSubjects[uploadPipeline]?.send(())
-    }
-
-    /// Build the Combine pipelines for all upload pipelines: subject → throttle → upload.
-    /// Must be called once during init().
-    func setupLanePipelines() {
-        for pipeline in NightscoutUploadPipeline.allCases {
-            guard let subject = uploadPipelineSubjects[pipeline], let window = uploadPipelineInterval[pipeline] else { continue }
-            subject
-                .receive(on: uploadPipelineQueue)
-                .throttle(for: .seconds(window), scheduler: uploadPipelineQueue, latest: false)
-                .sink { [weak self] in
-                    guard let self else { return }
-                    Task(priority: .utility) { await self.runUploadPipeline(pipeline) }
-                }
-                .store(in: &subscriptions)
+        Task(priority: .utility) { [weak self] in
+            await self?.uploadSerializer.request(uploadPipeline)
         }
     }
 
-    /// Runs the actual upload for a single upload pipeline.
-    /// Called by the throttled pipeline, not directly by callers.
-    func runUploadPipeline(_ uploadPipeline: NightscoutUploadPipeline) async {
+    /// Dispatches to a pipeline's upload routine. Runs inside the serializer; must only
+    /// call `performUpload*()` functions, never the public `upload*()` entry points.
+    private func performUpload(for uploadPipeline: NightscoutUploadPipeline) async {
         switch uploadPipeline {
-        case .carbs: await uploadCarbs()
-        case .pumpHistory: await uploadPumpHistory()
-        case .overrides: await uploadOverrides()
-        case .tempTargets: await uploadTempTargets()
-        case .glucose: await uploadGlucose()
+        case .carbs: await performUploadCarbs()
+        case .pumpHistory: await performUploadPumpHistory()
+        case .overrides: await performUploadOverrides()
+        case .tempTargets: await performUploadTempTargets()
+        case .glucose: await performUploadGlucose()
         case .deviceStatus:
-            do { try await uploadDeviceStatus() }
+            do { try await performUploadDeviceStatus() }
             catch { debug(.nightscout, "deviceStatus upload failed: \(error)") }
         }
     }
@@ -134,14 +108,11 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
 
     // MARK: - Upload triggers
 
-    //
     // Each upload pipeline is driven by an NSFetchedResultsController whose predicate is the
     // "not yet uploaded to Nightscout" set for that entity. The controller fires whenever
     // un-uploaded items appear (or drop out after a successful upload), which we map to a
-    // `requestUpload(pipeline)` call (throttled per pipeline). Bound to the viewContext, they
-    // also pick up batch-inserted glucose via the persistent history merge in CoreDataStack —
-    // replacing the previous changedObjects publisher plus the glucoseStorage.updatePublisher
-    // fallback.
+    // `requestUpload(pipeline)` call (coalesced and serialized per pipeline). Bound to the viewContext,
+    // they also pick up batch-inserted glucose via the persistent history merge in CoreDataStack.
 
     let determinationUploadControllerDelegate = FetchedResultsControllerDelegate()
     lazy var determinationUploadController: NSFetchedResultsController<OrefDetermination> = {
@@ -293,16 +264,20 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
 
     init(resolver: Resolver) {
         injectServices(resolver)
+
+        uploadSerializer = NightscoutUploadSerializer { [weak self] pipeline in
+            await self?.performUpload(for: pipeline)
+        }
+
         subscribe()
 
         setupNotification()
 
-        setupLanePipelines()
         wireSubscribers()
 
         /// Ensure that Nightscout Manager holds the `lastEnactedDetermination`, if one exists, on initialization.
         /// We have to set this here in `init()`, so there's a `lastEnactedDetermination` available after an app restart
-        /// for `uploadDeviceStatus()`, as within that fuction `lastEnactedDetermination` is reassigned at the very end of the function.
+        /// for `performUploadDeviceStatus()`, as within that function `lastEnactedDetermination` is reassigned at the very end of the function.
         /// This way, we ensure the latest enacted determination is always part of `devicestatus` and avoid having instances
         /// where the first uploaded non-enacted determination (i.e., "suggested"), lacks the "enacted" data.
         Task {
@@ -327,6 +302,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         }
     }
 
+    /// Direct upload triggers for App Intents / remote control, which await the upload
+    /// before ending their background task. Uploads are serialized per pipeline, so these
+    /// may fire alongside the Core Data triggers for the same change without double-uploading.
     func setupNotification() {
         Foundation.NotificationCenter.default.publisher(for: .willUpdateOverrideConfiguration)
             .sink { [weak self] _ in
@@ -554,8 +532,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     /// - Schedule a task to upload pod age data separately.
     ///
     /// - Note: Ensure `nightscoutAPI` is initialized and `isUploadEnabled` is set to `true` before invoking this function.
-    /// - Returns: Nothing.
-    func uploadDeviceStatus() async throws {
+    private func performUploadDeviceStatus() async throws {
         guard let nightscout = nightscoutAPI, isUploadEnabled else {
             debug(.nightscout, "NS API not available or upload disabled. Aborting NS Status upload.")
             return
@@ -914,7 +891,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         }
     }
 
-    func uploadGlucose() async {
+    private func performUploadGlucose() async {
         do {
             try await uploadGlucose(glucoseStorage.getGlucoseNotYetUploadedToNightscout())
             try await uploadNonCoreDataTreatments(glucoseStorage.getCGMStateNotYetUploadedToNightscout())
@@ -926,7 +903,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
         }
     }
 
-    func uploadPumpHistory() async {
+    private func performUploadPumpHistory() async {
         do {
             try await uploadPumpHistory(pumpHistoryStorage.getPumpHistoryNotYetUploadedToNightscout())
         } catch {
@@ -938,6 +915,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     func uploadCarbs() async {
+        await uploadSerializer.run(.carbs)
+    }
+
+    private func performUploadCarbs() async {
         do {
             try await uploadCarbs(carbsStorage.getCarbsNotYetUploadedToNightscout())
             try await uploadCarbs(carbsStorage.getFPUsNotYetUploadedToNightscout())
@@ -950,6 +931,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     func uploadOverrides() async {
+        await uploadSerializer.run(.overrides)
+    }
+
+    private func performUploadOverrides() async {
         do {
             try await uploadOverrides(overridesStorage.getOverridesNotYetUploadedToNightscout())
             try await uploadOverrideRuns(overridesStorage.getOverrideRunsNotYetUploadedToNightscout())
@@ -962,6 +947,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     }
 
     func uploadTempTargets() async {
+        await uploadSerializer.run(.tempTargets)
+    }
+
+    private func performUploadTempTargets() async {
         do {
             try await uploadTempTargets(await tempTargetsStorage.getTempTargetsNotYetUploadedToNightscout())
             try await uploadTempTargetRuns(await tempTargetsStorage.getTempTargetRunsNotYetUploadedToNightscout())

+ 3 - 3
Trio/Sources/Services/Network/Nightscout/NightscoutUploadPipeline.swift

@@ -1,8 +1,8 @@
 import Foundation
 
 /// Logical upload “paths” handled by NightscoutManager.
-/// Each upload pipeline has its own throttled queue so we don’t double-upload
-/// when multiple sources trigger the same work close together.
+/// Runs are coalesced and serialized per pipeline (see `NightscoutUploadSerializer`)
+/// so we don’t double-upload when multiple sources trigger the same work close together.
 public enum NightscoutUploadPipeline: String, CaseIterable {
     case carbs
     case pumpHistory
@@ -28,7 +28,7 @@ public extension Foundation.Notification.Name {
 }
 
 /// Convenience helper any component (e.g. APSManager) can call to
-/// request uploads. The work is enqueued and deduped per upload pipeline via throttle,
+/// request uploads. The work is coalesced and serialized per upload pipeline,
 /// so rapid duplicate calls won’t double-upload.
 ///
 /// - Parameters:

+ 96 - 0
Trio/Sources/Services/Network/Nightscout/NightscoutUploadSerializer.swift

@@ -0,0 +1,96 @@
+import Foundation
+
+/// Coalesces and serializes Nightscout upload runs per pipeline.
+///
+/// Runs of the same pipeline never overlap: concurrent runs would each fetch the
+/// rows still marked `isUploadedToNS == false` and POST them twice, and Nightscout
+/// stores concurrent identical POSTs as duplicates. A follow-up run re-fetches only
+/// after the previous run has marked its rows uploaded, so nothing is sent twice.
+///
+/// An idle pipeline starts a run immediately; while a run is in flight, exactly one
+/// follow-up is queued and further requests coalesce into it. No request is dropped.
+/// Every run executes the upload operation provided at init.
+///
+/// A `run(_:)` issued from inside its own pipeline's upload operation would deadlock
+/// awaiting itself; the serializer asserts in debug builds and downgrades the call to
+/// a fire-and-forget request in release.
+actor NightscoutUploadSerializer {
+    /// Uploads everything still pending for a pipeline.
+    private let uploadOperation: @Sendable(NightscoutUploadPipeline) async -> Void
+
+    /// Called when `run(_:)` is invoked from inside the same pipeline's upload
+    /// operation. Asserts by default; tests inject a recorder.
+    private let onReentrantRun: @Sendable(NightscoutUploadPipeline) -> Void
+
+    /// Pipeline whose upload operation the current task is executing, if any.
+    @TaskLocal private static var activePipeline: NightscoutUploadPipeline?
+
+    /// Run currently in flight per pipeline.
+    private var current: [NightscoutUploadPipeline: Task<Void, Never>] = [:]
+    /// Follow-up run queued behind the current one, at most one per pipeline.
+    private var queued: [NightscoutUploadPipeline: Task<Void, Never>] = [:]
+
+    init(
+        uploadOperation: @escaping @Sendable(NightscoutUploadPipeline) async -> Void,
+        onReentrantRun: @escaping @Sendable(NightscoutUploadPipeline) -> Void = { pipeline in
+            assertionFailure("run(.\(pipeline)) called from inside its own upload operation")
+        }
+    ) {
+        self.uploadOperation = uploadOperation
+        self.onReentrantRun = onReentrantRun
+    }
+
+    /// Fire-and-forget request. Coalesces into an already queued follow-up if present.
+    /// Safe to call from inside an upload operation.
+    func request(_ pipeline: NightscoutUploadPipeline) {
+        _ = scheduleRun(pipeline)
+    }
+
+    /// Awaitable request: returns once the run serving it has completed. For callers
+    /// that must know the upload attempt has finished before proceeding.
+    func run(_ pipeline: NightscoutUploadPipeline) async {
+        guard Self.activePipeline != pipeline else {
+            onReentrantRun(pipeline)
+            request(pipeline)
+            return
+        }
+        await scheduleRun(pipeline).value
+    }
+
+    /// Returns the run that serves a request made now: the queued follow-up if one
+    /// exists, a new follow-up chained behind the current run, or a fresh run.
+    private func scheduleRun(_ pipeline: NightscoutUploadPipeline) -> Task<Void, Never> {
+        if let followUp = queued[pipeline] {
+            return followUp
+        }
+        if let running = current[pipeline] {
+            let followUp = Task { [running, uploadOperation, weak self] in
+                await running.value
+                await self?.promoteQueuedRun(pipeline)
+                await Self.$activePipeline.withValue(pipeline) {
+                    await uploadOperation(pipeline)
+                }
+                await self?.finishCurrentRun(pipeline)
+            }
+            queued[pipeline] = followUp
+            return followUp
+        }
+        let run = Task { [uploadOperation, weak self] in
+            await Self.$activePipeline.withValue(pipeline) {
+                await uploadOperation(pipeline)
+            }
+            await self?.finishCurrentRun(pipeline)
+        }
+        current[pipeline] = run
+        return run
+    }
+
+    private func promoteQueuedRun(_ pipeline: NightscoutUploadPipeline) {
+        current[pipeline] = queued[pipeline]
+        queued[pipeline] = nil
+    }
+
+    private func finishCurrentRun(_ pipeline: NightscoutUploadPipeline) {
+        current[pipeline] = nil
+    }
+}

+ 216 - 0
TrioTests/NightscoutUploadSerializerTests.swift

@@ -0,0 +1,216 @@
+import Foundation
+import Testing
+
+@testable import Trio
+
+@Suite("Nightscout Upload Serializer Tests") struct NightscoutUploadSerializerTests {
+    /// Records whether an operation is currently in flight and flags any overlap.
+    private actor OverlapDetector {
+        private var inFlight = false
+        private(set) var overlapDetected = false
+        private(set) var completedRuns = 0
+
+        func enter() {
+            if inFlight { overlapDetected = true }
+            inFlight = true
+        }
+
+        func exit() {
+            inFlight = false
+            completedRuns += 1
+        }
+    }
+
+    /// Ordered log of named events across concurrent tasks, with a run counter.
+    private actor EventLog {
+        private(set) var events: [String] = []
+        private var runNumber = 0
+
+        func append(_ event: String) { events.append(event) }
+
+        func beginRun() -> Int {
+            runNumber += 1
+            return runNumber
+        }
+    }
+
+    /// One-shot gate that suspends waiters until opened.
+    private actor Gate {
+        private var isOpen = false
+        private var waiters: [CheckedContinuation<Void, Never>] = []
+
+        func open() {
+            isOpen = true
+            for waiter in waiters { waiter.resume() }
+            waiters.removeAll()
+        }
+
+        func wait() async {
+            if isOpen { return }
+            await withCheckedContinuation { waiters.append($0) }
+        }
+    }
+
+    /// Thrown when `waitUntil` gives up on its condition.
+    private struct TimeoutError: Error {}
+
+    /// Polls until `condition` is true, throwing after ~2 seconds so a broken
+    /// serializer fails the test at the wait site instead of hanging it.
+    private func waitUntil(_ condition: @escaping @Sendable() async -> Bool) async throws {
+        for _ in 0 ..< 400 {
+            if await condition() { return }
+            try await Task.sleep(nanoseconds: 5_000_000)
+        }
+        throw TimeoutError()
+    }
+
+    @Test("Concurrent runs of the same pipeline never overlap") func noOverlapWithinPipeline() async {
+        let detector = OverlapDetector()
+        let serializer = NightscoutUploadSerializer { _ in
+            await detector.enter()
+            try? await Task.sleep(nanoseconds: 20_000_000)
+            await detector.exit()
+        }
+
+        await withTaskGroup(of: Void.self) { group in
+            for _ in 0 ..< 10 {
+                group.addTask { await serializer.run(.overrides) }
+            }
+        }
+
+        #expect(await detector.overlapDetected == false, "No two runs of the same pipeline may be in flight at once")
+        #expect(await detector.completedRuns >= 1, "At least one run must execute")
+    }
+
+    @Test("A run requested mid-flight executes only after the current one finishes") func queuedRunWaitsForCurrent() async throws {
+        let log = EventLog()
+        let gate = Gate()
+        let serializer = NightscoutUploadSerializer { _ in
+            let n = await log.beginRun()
+            await log.append("start-\(n)")
+            if n == 1 { await gate.wait() }
+            await log.append("end-\(n)")
+        }
+
+        await serializer.request(.overrides)
+        try await waitUntil { await log.events.contains("start-1") }
+
+        await serializer.request(.overrides)
+        await gate.open()
+        try await waitUntil { await log.events.contains("end-2") }
+
+        #expect(await log.events == ["start-1", "end-1", "start-2", "end-2"])
+    }
+
+    @Test("Requests made while a run is in flight coalesce into a single follow-up run") func requestsCoalesceIntoOneFollowUp(
+    ) async throws {
+        let log = EventLog()
+        let gate = Gate()
+        let serializer = NightscoutUploadSerializer { _ in
+            let n = await log.beginRun()
+            await log.append("run-\(n)")
+            if n == 1 { await gate.wait() }
+        }
+
+        await serializer.request(.glucose)
+        try await waitUntil { await log.events.contains("run-1") }
+
+        // All five requests arrive while the first run is blocked, so they must
+        // coalesce into exactly one follow-up run.
+        for _ in 0 ..< 5 {
+            await serializer.request(.glucose)
+        }
+
+        await gate.open()
+        try await waitUntil { await log.events.contains("run-2") }
+        // Grace period: any extra (incorrect) follow-up runs would surface here.
+        try await Task.sleep(nanoseconds: 100_000_000)
+
+        #expect(await log.events == ["run-1", "run-2"], "Coalesced requests must produce exactly one follow-up run")
+    }
+
+    @Test("run returns only after its own operation has completed") func runAwaitsOwnOperation() async {
+        let log = EventLog()
+        let serializer = NightscoutUploadSerializer { _ in
+            let n = await log.beginRun()
+            await log.append("run-\(n)")
+        }
+
+        await serializer.run(.carbs)
+        #expect(await log.events == ["run-1"])
+
+        await serializer.run(.carbs)
+        #expect(await log.events == ["run-1", "run-2"])
+    }
+
+    /// Holds the serializer so an upload operation can call back into it.
+    private actor SerializerBox {
+        private var serializer: NightscoutUploadSerializer?
+
+        func set(_ serializer: NightscoutUploadSerializer) { self.serializer = serializer }
+        func get() -> NightscoutUploadSerializer? { serializer }
+    }
+
+    @Test(
+        "A re-entrant run from inside its own operation degrades to a follow-up instead of deadlocking"
+    ) func reentrantRunDoesNotDeadlock() async throws {
+        let log = EventLog()
+        let reentrantLog = EventLog()
+        let box = SerializerBox()
+
+        let serializer = NightscoutUploadSerializer(
+            uploadOperation: { _ in
+                let n = await log.beginRun()
+                await log.append("run-\(n)")
+                if n == 1 {
+                    // Forbidden re-entrant call; the guard must turn it into a queued follow-up.
+                    await box.get()?.run(.overrides)
+                }
+            },
+            onReentrantRun: { pipeline in
+                Task { await reentrantLog.append("reentrant-\(pipeline)") }
+            }
+        )
+        await box.set(serializer)
+
+        await serializer.request(.overrides)
+
+        try await waitUntil { await log.events.contains("run-2") }
+        try await waitUntil { await reentrantLog.events.isEmpty == false }
+
+        #expect(await log.events == ["run-1", "run-2"])
+        #expect(await reentrantLog.events == ["reentrant-overrides"])
+    }
+
+    @Test("Different pipelines run independently of each other") func pipelinesAreIndependent() async throws {
+        let log = EventLog()
+        let gate = Gate()
+        let serializer = NightscoutUploadSerializer { pipeline in
+            if pipeline == .overrides {
+                await log.append("overrides-start")
+                await gate.wait()
+                await log.append("overrides-end")
+            } else {
+                await log.append("tempTargets-done")
+            }
+        }
+
+        await serializer.request(.overrides)
+        try await waitUntil { await log.events.contains("overrides-start") }
+
+        // Safety valve: if pipelines were wrongly serialized against each other,
+        // the tempTargets run below would be stuck behind the gated overrides run.
+        // Opening the gate after a delay lets the test fail on ordering instead of hanging.
+        let safetyValve = Task {
+            try? await Task.sleep(nanoseconds: 2_000_000_000)
+            await gate.open()
+        }
+
+        await serializer.run(.tempTargets)
+        await gate.open()
+        try await waitUntil { await log.events.contains("overrides-end") }
+        safetyValve.cancel()
+
+        #expect(await log.events == ["overrides-start", "tempTargets-done", "overrides-end"])
+    }
+}